Skip to content

i18n: localize the 9.0.1 What's New title (unblock the coverage gate) - #514

Merged
ryanbr merged 1 commit into
mainfrom
fix/i18n-changelog-9-0-1-title
Jul 16, 2026
Merged

ryanbr merged 1 commit into
mainfrom
fix/i18n-changelog-9-0-1-title

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Why

The 9.0.1 release generated its What's New title into AppChangelog.kt as a raw Kotlin literal:

title = "German, French & Spanish, pull-to-sync on Today, and a wave of polish",   // ← raw

Every other entry uses a localized resource (uiString(R.string.l10n_app_changelog_…)). The full-tree i18n audit (Tools/i18n_audit.py) flags the raw title = "…" — and because i18n-coverage runs only on pull_request, never on the release's push to main, it landed unaudited and now red-checks every open PR (#507, #512, #513) on a line none of them touched.

Fix

Localize the title exactly like the prior entries:

  • AppChangelog.kt → title = uiString(R.string.l10n_app_changelog_german_french_spanish_pull_to_sync_1109bda2)
  • add that string to values/ (en) and the de / es / fr focus locales (the coverage half of the gate needs all three).

The key follows the established scheme — first-6-word slug + sha1(title)[:8] — verified to reproduce the existing 9.0.0 / 8.7.0 / 8.6.2 keys.

Verification

  • python3 Tools/i18n_audit.py --ci origin/main → OK no hardcoded literals (exit 0)
  • ./gradlew compileFullDebugKotlin → BUILD SUCCESSFUL (R.string resolves, XML well-formed)

Scope / follow-ups (not in this PR)

  • Apple unaffected: SwiftUI auto-extracts string literals, so the Swift changelog title passes the Apple audit as-is.
  • Prevent recurrence: either run the i18n audit on the release push, or have appchangelog-gen.py emit the localized key directly — otherwise the next release re-breaks the gate for all PRs the same way.

The 9.0.1 release generated its AppChangelog title as a raw Kotlin literal
(title = "German, French & Spanish, ..."), unlike every other entry which
uses uiString(R.string.l10n_app_changelog_...). The full-tree i18n audit flags
the raw literal, and since i18n-coverage runs only on pull_request (never on
the release's push to main), it landed unaudited and now red-checks every open
PR (#507, #512, #513) on a line none of them touched.

Localize it like the prior entries: title -> uiString(R.string.l10n_app_changelog_german_french_spanish_pull_to_sync_1109bda2),
with the string added to values/ (en) + the de/es/fr focus locales. Key follows
the established scheme (first-6-word slug + sha1(title)[:8]; verified it
reproduces the 9.0.0/8.7.0/8.6.2 keys).

i18n_audit.py --ci: OK no hardcoded literals. compileFullDebugKotlin: OK.
Apple side is unaffected (SwiftUI auto-extracts literals).
@ryanbr
ryanbr merged commit 47b1a72 into main Jul 16, 2026
1 check passed
@ryanbr
ryanbr deleted the fix/i18n-changelog-9-0-1-title branch July 16, 2026 11:09
ryanbr added a commit that referenced this pull request Jul 17, 2026
A release pushes STRAIGHT to main with no PR, and appchangelog-gen.py emits the
What's New title as a RAW Kotlin literal that has to be localized by hand
afterwards. With a PR-only trigger that landed completely unaudited — and
because --ci is a whole-tree gate rather than a diff, it then red-checked EVERY
open PR on a line none of them touched. That is exactly what 9.0.1 did (fixed
by #514): the break was invisible where it was introduced and highly visible
everywhere it wasn't.

Audit pushes to main so a release-introduced literal surfaces on main the
moment it lands, and gets fixed before any contributor trips over it. Same
8-second ubuntu job, no new cost.

github.base_ref is set only for pull_request and is empty on a push (which
would pass a bare 'origin/'), so fall back to main. The ref is retained for
workflow compatibility — --ci is a whole-tree gate, so the resolved value does
not change the verdict.

YAML parses (both triggers); the push path's exact command
'i18n_audit.py --ci origin/main' passes on current main.
ryanbr added a commit that referenced this pull request Jul 17, 2026
Numbers are main's current 292/203 +1, matching the previous staging commit's
pattern, so the .staging app upgrades cleanly over the last testing build (290)
and stays above main's released high-water.

Carries everything merged since the 9.0.1 release:
 - Workouts read HR under the recording strap, not a hardcoded id (#512 Kotlin,
   #513 Swift twin)
 - Oura 0x60/0x80 IBI decoders corrected to the ring's real byte layout (#511)
 - Whoop5Config enable_sig12 corrected to ASCII '1' (#522)
 - Sleep time edits save as one validated window (#525)
 - "Recompute this night" for deleted sleep windows (#526)
 - Stress marker values stay readable (#507)
 - Faster history sync + faster Bluetooth link, both EXPERIMENTAL and default-off
   (#536/#537/#538) - the whole point of this build is field data on those
 - i18n changelog-title fix + main-push audit (#514/#535)

versionName stays 9.0.2 here for the staging identity only; the real 9.0.2
release still bumps main separately (and must be raised past 293 first).
digitalerdude added a commit to digitalerdude/noop that referenced this pull request Jul 17, 2026
…variant

ci_check() audited the whole merged tree unconditionally once the ryanbr#453
backlog closed, ignoring base_ref entirely. That means a transient
regression on main (the 9.0.1 release generating a raw AppChangelog.kt
literal, fixed by ryanbr#514) red-flagged every open PR whose diff never
touched the offending file — and those PRs stayed red, since a
pull_request-triggered workflow doesn't re-run just because main
changed. ryanbr#508 and ryanbr#506 are still showing that stale failure right now,
over 5 hours after ryanbr#514 fixed main.

Restores the base_ref diff (Android side already had the git_show/
literals_at_ref scaffolding for this, just unused) across every check:
hardcoded literals, missing-translation keys, and format-string
mismatches, on both platforms. A violation already present on base_ref
isn't this PR's fault and no longer fails it; existing debt is still
printed for visibility. Today that's zero everywhere, so the practical
behavior on a healthy main is unchanged — this only changes what
happens when main itself regresses.
digitalerdude added a commit to digitalerdude/noop that referenced this pull request Jul 17, 2026
…variant

ci_check() audited the whole merged tree unconditionally once the ryanbr#453
backlog closed, ignoring base_ref entirely. That means a transient
regression on main (the 9.0.1 release generating a raw AppChangelog.kt
literal, fixed by ryanbr#514) red-flagged every open PR whose diff never
touched the offending file — and those PRs stayed red, since a
pull_request-triggered workflow doesn't re-run just because main
changed. ryanbr#508 and ryanbr#506 are still showing that stale failure right now,
over 5 hours after ryanbr#514 fixed main.

Restores the base_ref diff (Android side already had the git_show/
literals_at_ref scaffolding for this, just unused) across every check:
hardcoded literals, missing-translation keys, and format-string
mismatches, on both platforms. A violation already present on base_ref
isn't this PR's fault and no longer fails it; existing debt is still
printed for visibility. Today that's zero everywhere, so the practical
behavior on a healthy main is unchanged — this only changes what
happens when main itself regresses.
digitalerdude added a commit to digitalerdude/noop that referenced this pull request Jul 19, 2026
…variant

ci_check() audited the whole merged tree unconditionally once the ryanbr#453
backlog closed, ignoring base_ref entirely. That means a transient
regression on main (the 9.0.1 release generating a raw AppChangelog.kt
literal, fixed by ryanbr#514) red-flagged every open PR whose diff never
touched the offending file — and those PRs stayed red, since a
pull_request-triggered workflow doesn't re-run just because main
changed. ryanbr#508 and ryanbr#506 are still showing that stale failure right now,
over 5 hours after ryanbr#514 fixed main.

Restores the base_ref diff (Android side already had the git_show/
literals_at_ref scaffolding for this, just unused) across every check:
hardcoded literals, missing-translation keys, and format-string
mismatches, on both platforms. A violation already present on base_ref
isn't this PR's fault and no longer fails it; existing debt is still
printed for visibility. Today that's zero everywhere, so the practical
behavior on a healthy main is unchanged — this only changes what
happens when main itself regresses.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…ryanbr#514)

The 9.0.1 release generated its AppChangelog title as a raw Kotlin literal
(title = "German, French & Spanish, ..."), unlike every other entry which
uses uiString(R.string.l10n_app_changelog_...). The full-tree i18n audit flags
the raw literal, and since i18n-coverage runs only on pull_request (never on
the release's push to main), it landed unaudited and now red-checks every open
PR (ryanbr#507, ryanbr#512, ryanbr#513) on a line none of them touched.

Localize it like the prior entries: title -> uiString(R.string.l10n_app_changelog_german_french_spanish_pull_to_sync_1109bda2),
with the string added to values/ (en) + the de/es/fr focus locales. Key follows
the established scheme (first-6-word slug + sha1(title)[:8]; verified it
reproduces the 9.0.0/8.7.0/8.6.2 keys).

i18n_audit.py --ci: OK no hardcoded literals. compileFullDebugKotlin: OK.
Apple side is unaffected (SwiftUI auto-extracts literals).
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
A release pushes STRAIGHT to main with no PR, and appchangelog-gen.py emits the
What's New title as a RAW Kotlin literal that has to be localized by hand
afterwards. With a PR-only trigger that landed completely unaudited — and
because --ci is a whole-tree gate rather than a diff, it then red-checked EVERY
open PR on a line none of them touched. That is exactly what 9.0.1 did (fixed
by ryanbr#514): the break was invisible where it was introduced and highly visible
everywhere it wasn't.

Audit pushes to main so a release-introduced literal surfaces on main the
moment it lands, and gets fixed before any contributor trips over it. Same
8-second ubuntu job, no new cost.

github.base_ref is set only for pull_request and is empty on a push (which
would pass a bare 'origin/'), so fall back to main. The ref is retained for
workflow compatibility — --ci is a whole-tree gate, so the resolved value does
not change the verdict.

YAML parses (both triggers); the push path's exact command
'i18n_audit.py --ci origin/main' passes on current main.
digitalerdude added a commit to digitalerdude/noop that referenced this pull request Jul 25, 2026
…variant

ci_check() audited the whole merged tree unconditionally once the ryanbr#453
backlog closed, ignoring base_ref entirely. That means a transient
regression on main (the 9.0.1 release generating a raw AppChangelog.kt
literal, fixed by ryanbr#514) red-flagged every open PR whose diff never
touched the offending file — and those PRs stayed red, since a
pull_request-triggered workflow doesn't re-run just because main
changed. ryanbr#508 and ryanbr#506 are still showing that stale failure right now,
over 5 hours after ryanbr#514 fixed main.

Restores the base_ref diff (Android side already had the git_show/
literals_at_ref scaffolding for this, just unused) across every check:
hardcoded literals, missing-translation keys, and format-string
mismatches, on both platforms. A violation already present on base_ref
isn't this PR's fault and no longer fails it; existing debt is still
printed for visibility. Today that's zero everywhere, so the practical
behavior on a healthy main is unchanged — this only changes what
happens when main itself regresses.
ryanbr added a commit that referenced this pull request Jul 27, 2026
Kotlin and Swift attach a doc block to the declaration that FOLLOWS it, and keep only the
LAST of several stacked ones. Insert a block above an existing one, or a declaration
into the gap between a comment and the thing it documents, and the text is silently
orphaned — still there, still above the right function, still correct prose, but no
longer reaching Dokka or IDE hover. That is why review does not catch it.

#846 fixed three sites in one day, one of which #842 had introduced that same morning,
and the block that came adrift was "Byte-parity twin of Swift classifyCoverage" — a
parity-contract statement, and parity is the first rule in CLAUDE.md. That is the case
for gating rather than only fixing.

Tools/doc_comment_lint.py catches both detaching patterns across Kotlin and Swift:
stacked blocks, and a blank line between a doc block and its declaration. File headers
(a blank line before package/import/@file:) are exempt, since that spacing is
conventional and correct.

Corrects #846, which claimed the tree was clean afterwards. It was not: that sweep only
matched SINGLE-LINE blocks. Handling multi-line blocks finds 25 more pre-existing sites
across 13 files — 8 in WhoopBleClient.kt alone, and AppViewModel.kt:68 is a class doc
detached by an inserted data class, the same shape as the #842 slip.

Those 25 are grandfathered per-file so the gate starts green and blocks only NEW ones.
No existing code can red-check a contributor on a line they did not touch — the #514
failure mode. Baselined by COUNT, not line number: line numbers churn on every edit
above them, which keeps the file stale and trains people to regenerate it without
reading it. A count moves only when someone adds or removes a site. It ratchets DOWN —
fixing one prints an IMPROVED line asking for the allowance to be lowered.

Verified five ways: passes on clean main; catches all three pre-#846 sites when
reconstructed; fails on a new site in an unbaselined file; fails on an EXTRA site in an
already-baselined file (so the count is enforced, not mere presence); and clears nine
false-positive probes including file headers, a doc above an annotation, and a `*/`
appearing inside prose.

Pure Python on ubuntu, six seconds. No Xcode or Gradle, so unlike the app-target and
Android checks it actually runs while app-build.yml and android.yml are disabled. Its
own job gates itself. New workflow rather than a step in i18n-coverage.yml: different
concern, and that job is currently the only thing gating PRs — a docs regression should
not report itself as an i18n failure.

Not included: fixing the 25. Each needs a judgement call about how to merge two blocks,
and doing 25 of those at volume is how the mistake gets made. The baseline makes them
visible and burnable-down without blocking anyone. Note the 25 are machine-detected;
three were spot-checked as genuine, the rest are grandfathered either way.
ryanbr added a commit that referenced this pull request Jul 27, 2026
Tools/i18n_audit.py:34 was LANGS = ["de","es","fr","pt-PT"]. The Apple catalogs ship
EIGHT non-English localizations, so the gate covered four of them — and the four it did
not cover drifted exactly as you would expect:

  catalog                     de/es/fr/pt-PT   it     ru    zh-Hans  zh-Hant
  Strand (3255 keys)                100%      3086   3110    3136     3086
  StrandDesign (95)                 100%        14     41      20       14
  NOOPWatch (52)                    100%        49     50      49       49
  NOOPWatchComplications (30)       100%         5     30       5        5

The gated four are at 100% everywhere. The ungated four are missing up to 85% —
StrandDesign ships 14 of 95 Italian, the watch complications 5 of 30. That is the gate
working where it is pointed and nothing happening where it is not. Android is the same:
ANDROID_LOCALE_DIRS hardcoded the same four, values-zh existed on disk, was never
compared, and sat 43 keys behind. That is how #834 passed CI while adding a key to four
of five locale files (#844).

Both lists are now DISCOVERED — Apple languages from the union of each catalog own
localizations, Android dirs from values-* on disk — so a locale is covered the day it
appears rather than the day someone remembers a constant.

LANGS keeps ZERO tolerance and is untouched: those four are perfect precisely because
they are hard-gated. The newly covered locales carry 1019 real pre-existing gaps and are
gated against a ratcheting allowance instead; switching them on hard would red-check
every open PR on lines nobody touched, the exact #514 failure this workflow own comments
warn about. Counts rather than key lists, same reasoning as doc_comment_lint_baseline
(#849) — a key list goes stale on every edit and trains people to regenerate it unread.
Ratchets DOWN, printing IMPROVED when a count drops and STALE for an allowance whose
target no longer exists.

catalog_summary() had the same blind spot and is fixed too: it reported four of eight
languages, so the human-readable coverage view read 0 missing across the board. That is
very likely WHY this went unnoticed — anyone checking coverage was shown a clean sheet.
It now doubles as the burn-down view for the 1019.

Verified: green on main; fires when the values-zh gap grows 43->44; fires when one
StrandDesign Italian string is removed; de still hard-fails on a single missing key, so
zero tolerance is intact; a fabricated allowance target is reported STALE; and the
locale section states its own verdict even when an earlier check failed (it keyed on the
global failed flag, so a broken German string silenced it entirely).

Translates nothing. It stops the hole growing and makes the debt countable. Whether
it/ru/zh-* should ship at 14/95 or be withheld until filled is a product call, not a
linter one.
digitalerdude added a commit to digitalerdude/noop that referenced this pull request Jul 27, 2026
…variant

ci_check() audited the whole merged tree unconditionally once the ryanbr#453
backlog closed, ignoring base_ref entirely. That means a transient
regression on main (the 9.0.1 release generating a raw AppChangelog.kt
literal, fixed by ryanbr#514) red-flagged every open PR whose diff never
touched the offending file — and those PRs stayed red, since a
pull_request-triggered workflow doesn't re-run just because main
changed. ryanbr#508 and ryanbr#506 are still showing that stale failure right now,
over 5 hours after ryanbr#514 fixed main.

Restores the base_ref diff (Android side already had the git_show/
literals_at_ref scaffolding for this, just unused) across every check:
hardcoded literals, missing-translation keys, and format-string
mismatches, on both platforms. A violation already present on base_ref
isn't this PR's fault and no longer fails it; existing debt is still
printed for visibility. Today that's zero everywhere, so the practical
behavior on a healthy main is unchanged — this only changes what
happens when main itself regresses.
digitalerdude added a commit to digitalerdude/noop that referenced this pull request Jul 27, 2026
The 9.2.0 release generated its Android AppChangelog title as a raw Kotlin
literal instead of uiString(R.string....), the same pattern as ryanbr#514. The
i18n audit's regression gate now catches it on every open PR that merges
against the current main, including this one, even though this branch
never touched that file.

Localize it like the other entries: uiString(R.string.
l10n_app_changelog_hrv_accuracy_fix_oura_sleep_and_4ed31e39), with the
string added to values/ (en) + de/es/fr/pt-rPT (focus locales) and zh
(tracked under the ratcheting non-focus-locale allowance). Key follows the
established scheme (first-6-word slug + sha1(title)[:8]).

i18n_audit.py --ci: OK, no new hardcoded literals, no new locale gaps.
compileFullDebugKotlin: OK.
ryanbr pushed a commit that referenced this pull request Aug 2, 2026
* Distinguish "connected, no data" from "not connected" (#612)

The strap-recording chip only checked `connected` in its first branch
(`.recording`). A strap that is genuinely connected but has never once
synced — a fresh pairing, or a WHOOP-4/generic strap whose recent offloads
are a sustained streak of empty — fell through to `.notRecording` and showed
"Strap not connected. Tap to connect.", which is simply false: the link is up.
That's the exact symptom #612 describes ("the app doesn't say what's really
happening") for the case that isn't already covered by #580's
`.historyExperimental` override (which still takes priority for a WHOOP 5/MG
past its 2-empty-offload threshold — unchanged here).

Adds `LiveState.sustainedEmptyOffload`, exposing the WHOOP-4/generic
empty-offload streak (`EmptySyncTracker` / Android's twin) that was
previously only reachable as free text in `lastSyncError`. Set on every
HISTORY_COMPLETE (so a productive sync clears it immediately), reset on
disconnect (display flag only — the underlying streak counter deliberately
survives a reconnect, unchanged). `RecordingState` gains `.connectedNoData`:
connected, no live HR, and either never-synced or sustained-empty. Kotlin
gets the identical case, param and priority order in `TodayScoring.kt`,
per `RecordingState`'s own doc-comment promise of a 1:1 Kotlin mirror —
though note Android's `RecordingStatusChip`/`recordingStateFor` are
currently unwired into any screen (pre-existing, unrelated gap; will flag
separately).

Deliberately not done: no new, more sensitive trigger (the existing 3×/2×
consecutive-empty thresholds are unchanged), and no clickable single-bond
README hint (belongs a layer above the pure `RecordingState` enum — a
smaller follow-up).

Verified: xcodebuild build for Strand (macOS) and NOOPiOS.
StrandTests/TodayExplainabilityTests (46/46, forcing -testLanguage en
-testRegion US — this Mac's system locale is German, which the existing
copy tests are sensitive to regardless of this change).
`./gradlew compileFullDebugKotlin` clean. Android's `testFullDebugUnitTest`
could not be run locally — an unrelated, pre-existing gap (three
FakeRegistryDao test fakes are missing ~25 methods DeviceRegistryDao now
declares) fails test compilation for the whole module; filed separately,
not touched here.

Not verified on real hardware this session (no strap connected). Both the
regression case (a healthy WHOOP 5.0 session still reading "Recording") and
the new `.connectedNoData` case (a fresh pairing, or a WHOOP-4/generic
strap's 3rd consecutive empty offload) need a live check before this leaves
draft. No Android device+strap test setup either way — that side is
compile/unit-test verified only.

* Add the missing String Catalog entries for connectedNoData (#612 review)

RecordingState.connectedNoData's detail and accessibilityText used
String(localized:), the right mechanism, but the entries never got added to
Strand/Resources/Localizable.xcstrings — so de/es/fr/pt-PT (and the other
languages already carried by every neighbouring chip string) fell back to
English. i18n_audit's CI gate couldn't see this: it verifies translation
completeness for keys already IN the catalog, not for strings that never
made it in at all (the same blind spot as the Text(variable) gap #245's
review caught).

Verified: Tools/i18n_audit.py --ci origin/main clean, StrandTests
TodayExplainabilityTests 46/46 (-testLanguage en -testRegion US).

* i18n: localize the 9.2.0 changelog title (unblock the coverage gate)

The 9.2.0 release generated its Android AppChangelog title as a raw Kotlin
literal instead of uiString(R.string....), the same pattern as #514. The
i18n audit's regression gate now catches it on every open PR that merges
against the current main, including this one, even though this branch
never touched that file.

Localize it like the other entries: uiString(R.string.
l10n_app_changelog_hrv_accuracy_fix_oura_sleep_and_4ed31e39), with the
string added to values/ (en) + de/es/fr/pt-rPT (focus locales) and zh
(tracked under the ratcheting non-focus-locale allowance). Key follows the
established scheme (first-6-word slug + sha1(title)[:8]).

i18n_audit.py --ci: OK, no new hardcoded literals, no new locale gaps.
compileFullDebugKotlin: OK.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ryanbr#514)

The 9.0.1 release generated its AppChangelog title as a raw Kotlin literal
(title = "German, French & Spanish, ..."), unlike every other entry which
uses uiString(R.string.l10n_app_changelog_...). The full-tree i18n audit flags
the raw literal, and since i18n-coverage runs only on pull_request (never on
the release's push to main), it landed unaudited and now red-checks every open
PR (ryanbr#507, ryanbr#512, ryanbr#513) on a line none of them touched.

Localize it like the prior entries: title -> uiString(R.string.l10n_app_changelog_german_french_spanish_pull_to_sync_1109bda2),
with the string added to values/ (en) + the de/es/fr focus locales. Key follows
the established scheme (first-6-word slug + sha1(title)[:8]; verified it
reproduces the 9.0.0/8.7.0/8.6.2 keys).

i18n_audit.py --ci: OK no hardcoded literals. compileFullDebugKotlin: OK.
Apple side is unaffected (SwiftUI auto-extracts literals).
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
A release pushes STRAIGHT to main with no PR, and appchangelog-gen.py emits the
What's New title as a RAW Kotlin literal that has to be localized by hand
afterwards. With a PR-only trigger that landed completely unaudited — and
because --ci is a whole-tree gate rather than a diff, it then red-checked EVERY
open PR on a line none of them touched. That is exactly what 9.0.1 did (fixed
by ryanbr#514): the break was invisible where it was introduced and highly visible
everywhere it wasn't.

Audit pushes to main so a release-introduced literal surfaces on main the
moment it lands, and gets fixed before any contributor trips over it. Same
8-second ubuntu job, no new cost.

github.base_ref is set only for pull_request and is empty on a push (which
would pass a bare 'origin/'), so fall back to main. The ref is retained for
workflow compatibility — --ci is a whole-tree gate, so the resolved value does
not change the verdict.

YAML parses (both triggers); the push path's exact command
'i18n_audit.py --ci origin/main' passes on current main.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
Kotlin and Swift attach a doc block to the declaration that FOLLOWS it, and keep only the
LAST of several stacked ones. Insert a block above an existing one, or a declaration
into the gap between a comment and the thing it documents, and the text is silently
orphaned — still there, still above the right function, still correct prose, but no
longer reaching Dokka or IDE hover. That is why review does not catch it.

ryanbr#846 fixed three sites in one day, one of which ryanbr#842 had introduced that same morning,
and the block that came adrift was "Byte-parity twin of Swift classifyCoverage" — a
parity-contract statement, and parity is the first rule in CLAUDE.md. That is the case
for gating rather than only fixing.

Tools/doc_comment_lint.py catches both detaching patterns across Kotlin and Swift:
stacked blocks, and a blank line between a doc block and its declaration. File headers
(a blank line before package/import/@file:) are exempt, since that spacing is
conventional and correct.

Corrects ryanbr#846, which claimed the tree was clean afterwards. It was not: that sweep only
matched SINGLE-LINE blocks. Handling multi-line blocks finds 25 more pre-existing sites
across 13 files — 8 in WhoopBleClient.kt alone, and AppViewModel.kt:68 is a class doc
detached by an inserted data class, the same shape as the ryanbr#842 slip.

Those 25 are grandfathered per-file so the gate starts green and blocks only NEW ones.
No existing code can red-check a contributor on a line they did not touch — the ryanbr#514
failure mode. Baselined by COUNT, not line number: line numbers churn on every edit
above them, which keeps the file stale and trains people to regenerate it without
reading it. A count moves only when someone adds or removes a site. It ratchets DOWN —
fixing one prints an IMPROVED line asking for the allowance to be lowered.

Verified five ways: passes on clean main; catches all three pre-ryanbr#846 sites when
reconstructed; fails on a new site in an unbaselined file; fails on an EXTRA site in an
already-baselined file (so the count is enforced, not mere presence); and clears nine
false-positive probes including file headers, a doc above an annotation, and a `*/`
appearing inside prose.

Pure Python on ubuntu, six seconds. No Xcode or Gradle, so unlike the app-target and
Android checks it actually runs while app-build.yml and android.yml are disabled. Its
own job gates itself. New workflow rather than a step in i18n-coverage.yml: different
concern, and that job is currently the only thing gating PRs — a docs regression should
not report itself as an i18n failure.

Not included: fixing the 25. Each needs a judgement call about how to merge two blocks,
and doing 25 of those at volume is how the mistake gets made. The baseline makes them
visible and burnable-down without blocking anyone. Note the 25 are machine-detected;
three were spot-checked as genuine, the rest are grandfathered either way.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…nbr#850)

Tools/i18n_audit.py:34 was LANGS = ["de","es","fr","pt-PT"]. The Apple catalogs ship
EIGHT non-English localizations, so the gate covered four of them — and the four it did
not cover drifted exactly as you would expect:

  catalog                     de/es/fr/pt-PT   it     ru    zh-Hans  zh-Hant
  Strand (3255 keys)                100%      3086   3110    3136     3086
  StrandDesign (95)                 100%        14     41      20       14
  NOOPWatch (52)                    100%        49     50      49       49
  NOOPWatchComplications (30)       100%         5     30       5        5

The gated four are at 100% everywhere. The ungated four are missing up to 85% —
StrandDesign ships 14 of 95 Italian, the watch complications 5 of 30. That is the gate
working where it is pointed and nothing happening where it is not. Android is the same:
ANDROID_LOCALE_DIRS hardcoded the same four, values-zh existed on disk, was never
compared, and sat 43 keys behind. That is how ryanbr#834 passed CI while adding a key to four
of five locale files (ryanbr#844).

Both lists are now DISCOVERED — Apple languages from the union of each catalog own
localizations, Android dirs from values-* on disk — so a locale is covered the day it
appears rather than the day someone remembers a constant.

LANGS keeps ZERO tolerance and is untouched: those four are perfect precisely because
they are hard-gated. The newly covered locales carry 1019 real pre-existing gaps and are
gated against a ratcheting allowance instead; switching them on hard would red-check
every open PR on lines nobody touched, the exact ryanbr#514 failure this workflow own comments
warn about. Counts rather than key lists, same reasoning as doc_comment_lint_baseline
(ryanbr#849) — a key list goes stale on every edit and trains people to regenerate it unread.
Ratchets DOWN, printing IMPROVED when a count drops and STALE for an allowance whose
target no longer exists.

catalog_summary() had the same blind spot and is fixed too: it reported four of eight
languages, so the human-readable coverage view read 0 missing across the board. That is
very likely WHY this went unnoticed — anyone checking coverage was shown a clean sheet.
It now doubles as the burn-down view for the 1019.

Verified: green on main; fires when the values-zh gap grows 43->44; fires when one
StrandDesign Italian string is removed; de still hard-fails on a single missing key, so
zero tolerance is intact; a fabricated allowance target is reported STALE; and the
locale section states its own verdict even when an earlier check failed (it keyed on the
global failed flag, so a broken German string silenced it entirely).

Translates nothing. It stops the hole growing and makes the debt countable. Whether
it/ru/zh-* should ship at 14/95 or be withheld until filled is a product call, not a
linter one.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…yanbr#826)

* Distinguish "connected, no data" from "not connected" (ryanbr#612)

The strap-recording chip only checked `connected` in its first branch
(`.recording`). A strap that is genuinely connected but has never once
synced — a fresh pairing, or a WHOOP-4/generic strap whose recent offloads
are a sustained streak of empty — fell through to `.notRecording` and showed
"Strap not connected. Tap to connect.", which is simply false: the link is up.
That's the exact symptom ryanbr#612 describes ("the app doesn't say what's really
happening") for the case that isn't already covered by ryanbr#580's
`.historyExperimental` override (which still takes priority for a WHOOP 5/MG
past its 2-empty-offload threshold — unchanged here).

Adds `LiveState.sustainedEmptyOffload`, exposing the WHOOP-4/generic
empty-offload streak (`EmptySyncTracker` / Android's twin) that was
previously only reachable as free text in `lastSyncError`. Set on every
HISTORY_COMPLETE (so a productive sync clears it immediately), reset on
disconnect (display flag only — the underlying streak counter deliberately
survives a reconnect, unchanged). `RecordingState` gains `.connectedNoData`:
connected, no live HR, and either never-synced or sustained-empty. Kotlin
gets the identical case, param and priority order in `TodayScoring.kt`,
per `RecordingState`'s own doc-comment promise of a 1:1 Kotlin mirror —
though note Android's `RecordingStatusChip`/`recordingStateFor` are
currently unwired into any screen (pre-existing, unrelated gap; will flag
separately).

Deliberately not done: no new, more sensitive trigger (the existing 3×/2×
consecutive-empty thresholds are unchanged), and no clickable single-bond
README hint (belongs a layer above the pure `RecordingState` enum — a
smaller follow-up).

Verified: xcodebuild build for Strand (macOS) and NOOPiOS.
StrandTests/TodayExplainabilityTests (46/46, forcing -testLanguage en
-testRegion US — this Mac's system locale is German, which the existing
copy tests are sensitive to regardless of this change).
`./gradlew compileFullDebugKotlin` clean. Android's `testFullDebugUnitTest`
could not be run locally — an unrelated, pre-existing gap (three
FakeRegistryDao test fakes are missing ~25 methods DeviceRegistryDao now
declares) fails test compilation for the whole module; filed separately,
not touched here.

Not verified on real hardware this session (no strap connected). Both the
regression case (a healthy WHOOP 5.0 session still reading "Recording") and
the new `.connectedNoData` case (a fresh pairing, or a WHOOP-4/generic
strap's 3rd consecutive empty offload) need a live check before this leaves
draft. No Android device+strap test setup either way — that side is
compile/unit-test verified only.

* Add the missing String Catalog entries for connectedNoData (ryanbr#612 review)

RecordingState.connectedNoData's detail and accessibilityText used
String(localized:), the right mechanism, but the entries never got added to
Strand/Resources/Localizable.xcstrings — so de/es/fr/pt-PT (and the other
languages already carried by every neighbouring chip string) fell back to
English. i18n_audit's CI gate couldn't see this: it verifies translation
completeness for keys already IN the catalog, not for strings that never
made it in at all (the same blind spot as the Text(variable) gap ryanbr#245's
review caught).

Verified: Tools/i18n_audit.py --ci origin/main clean, StrandTests
TodayExplainabilityTests 46/46 (-testLanguage en -testRegion US).

* i18n: localize the 9.2.0 changelog title (unblock the coverage gate)

The 9.2.0 release generated its Android AppChangelog title as a raw Kotlin
literal instead of uiString(R.string....), the same pattern as ryanbr#514. The
i18n audit's regression gate now catches it on every open PR that merges
against the current main, including this one, even though this branch
never touched that file.

Localize it like the other entries: uiString(R.string.
l10n_app_changelog_hrv_accuracy_fix_oura_sleep_and_4ed31e39), with the
string added to values/ (en) + de/es/fr/pt-rPT (focus locales) and zh
(tracked under the ratcheting non-focus-locale allowance). Key follows the
established scheme (first-6-word slug + sha1(title)[:8]).

i18n_audit.py --ci: OK, no new hardcoded literals, no new locale gaps.
compileFullDebugKotlin: OK.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant